[FLINK-39964][state] Fix unrestorable checkpoint after CLAIM restore from native savepoint#28709
[FLINK-39964][state] Fix unrestorable checkpoint after CLAIM restore from native savepoint#28709Savonitar wants to merge 5 commits into
Conversation
…ry twice testSavepointDeepCopy listed savepointPath1 for both stateFiles1 and stateFiles2, so the copied-files assertion compared savepoint1's directory with itself and savepoint2's directory was never examined at all. Point stateFiles2 at savepointPath2, as the variable name, the assertion messages, and the test's documented steps always intended. The mix-up was introduced when the getFileNamesInDirectory helper was extracted (f125067); before that, both checks listed savepointPath2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…from native savepoint The first incremental checkpoint after a CLAIM-mode restore from a native savepoint reuses the savepoint's SST files as RelativeFileStateHandles. The metadata serializer stores only their relative path, so on restore they are resolved against the new checkpoint's exclusive directory and can no longer be found. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
02aeaf4 to
e84e8bb
Compare
| /** | ||
| * Stores the checkpoint metadata without knowing the checkpoint's exclusive directory: relative | ||
| * file references are always persisted as relative and are resolved against whatever directory | ||
| * the metadata is later read from. When writing the metadata of an actual checkpoint or | ||
| * savepoint, prefer {@link #storeCheckpointMetadata(CheckpointMetadata, | ||
| * CheckpointMetadataOutputStream)}. | ||
| * | ||
| * <p>The deliberately different method name keeps the context-free variants out of the {@code | ||
| * storeCheckpointMetadata} overload set, so a caller cannot switch between the two encodings by | ||
| * merely changing a variable's static type. | ||
| */ | ||
| public static void storeCheckpointMetadataWithoutExclusiveDir( | ||
| CheckpointMetadata checkpointMetadata, OutputStream out) throws IOException { | ||
|
|
||
| DataOutputStream dos = new DataOutputStream(out); | ||
| storeCheckpointMetadata(checkpointMetadata, dos); | ||
| storeCheckpointMetadataWithoutExclusiveDir(checkpointMetadata, dos); | ||
| } | ||
|
|
||
| /** | ||
| * Stores the checkpoint metadata into the given metadata output stream, passing the stream's | ||
| * exclusive checkpoint directory to the serializer so that relative file references stay | ||
| * self-consistent on recovery. | ||
| */ |
There was a problem hiding this comment.
After reading those two java docs, I don't understand the difference between the two methods.
What is the difference between:
- relative file references are always persisted as relative and are resolved against whatever directory the metadata is later read from
- relative file references stay self-consistent on recovery.
?
Maybe me and this java doc is lacking explanation of what are exclusive directories?
There was a problem hiding this comment.
what are exclusive directories?
Just to clarify, this PR uses pre-PR dictionary. E.g. in https://github.com/apache/flink/blob/master/flink-runtime/src/main/java/org/apache/flink/runtime/checkpoint/metadata/MetadataV2V3SerializerBase.java#L934 or in https://github.com/apache/flink/blob/master/flink-runtime/src/main/java/org/apache/flink/runtime/state/CheckpointedStateScope.java#L31 we already have this term.
That's why I "reused" it. I'm happy to change it, if you can come up with the better option.
The exclusive directory is the per-checkpoint directory that holds _metadata and the checkpoint's non-shared state files, e.g. .../chk-100/, as opposed to the job-wide shared/ directory.
difference between the two methods.
-
storeCheckpointMetadataWithoutExclusiveDir(...): pre-PR behaviour, every relative handle is written relative unconditionally. It is correct behavior when the caller guarantees every referenced file is located next to the metadata (or will be located). State processor API establishes exactly this guarantee by physically copying the files -
storeCheckpointMetadata(...): the serializer knows which exclusive directory it is writing into and checks every RelativeFileStateHandle against it. A handle whose file really lives there keeps the relative encoding, a handle pointing elsewhere (the reused savepoint SST from this bug) is written with its absolute path, because the relative form would be attched to chk-.../ on read and point at a file that was never there. That is what "self-consistent on recovery" meant: nothing in the written metadata resolves to a wrong location.
I will work on these java docs.
| // Must stay on the variant WITHOUT the exclusive directory. Files | ||
| // retained from an existing savepoint are copied into this | ||
| // directory by FileCopyFunction, but their handles still reference | ||
| // the source savepoint. Writing without the exclusive directory | ||
| // keeps the relative (file-name-only) encoding, which resolves | ||
| // against this directory on restore and keeps the savepoint | ||
| // self-contained. The exclusive-dir-aware | ||
| // Checkpoints.storeCheckpointMetadata would instead fill this | ||
| // savepoint's metadata with absolute paths pointing into the | ||
| // source savepoint, breaking this savepoint once the source is | ||
| // deleted. | ||
| Checkpoints.storeCheckpointMetadataWithoutExclusiveDir( | ||
| metadata, out); |
There was a problem hiding this comment.
I don't understand this change and this comment doesn't help me 😓
Files retained from an existing savepoint are copied into this directory by FileCopyFunction
FileCopyFunction what's that? Who is copying what? SavepointOutputFormat doesn't have to be invovled in copy any files AFAIU.
Looking at the name, Checkpoints.storeCheckpointMetadata vs Checkpoints.storeCheckpointMetadataWithoutExclusiveDir, I don't understand how that connects to:
storeCheckpointMetadata would instead fill this savepoint's metadata with absolute paths pointing into the source savepoint
breaking this savepoint once the source is deleted.
Again I'm confused who is copying what, and why are we talking about source in this class/method.
There was a problem hiding this comment.
FileCopyFunction what's that?
FileCopyFunction is a state processor API thing.
Who is copying what? SavepointOutputFormat doesn't have to be invovled in copy any files AFAIU.
SavepointWriter.write() builds a batch job with two sinks:
- FileCopyFunction: copies every state file referenced by the carried-over operator states into the new savepoint directory, keeping the file name.
- SavepointOutputFormat: writes the _metadata. So you're right and this class copies nothing but the other sink of the same job (FileCopyFunction) does.
Again I'm confused who is copying what, and why are we talking about source in this class/method.
The "source" is the savepoint from which one we build new one, the carried-over handles still record paths inside it, nothing rewrites them. With storeCheckpointMetadataWithoutExclusiveDir the metadata stores only file names, which resolve to the local copies on restore, so the source can be deleted.
I updated the comment significantly with this context in 3d0dda1
| * <p>This is a convenience variant of {@link #serialize(CheckpointMetadata, DataOutputStream, | ||
| * Path)} with a {@code null} exclusive directory: relative file references keep the relative | ||
| * encoding unconditionally and are resolved against whatever directory the metadata is later | ||
| * read from, which is only correct if every referenced file actually lives in that directory. |
There was a problem hiding this comment.
When exclusive dir is null, and file resides outside of the _metadata's directory, shouldn't we support relative paths that are pointing away? For example files structure:
/bar/claimed-files/chk-1/_old_metadata
/bar/claimed-files/chk-1/foo/1.sst
/bar/checkpoints/chk-2/_metadata
AFAIU the problem in this bug is that 1.sst would be referenced in _metadata as foo/1.sst which would be incorrect. And with your fix, we would store /bar/claimed-files/chk-1/foo/1.sst. Shouldn't we instead use ../../claimed-files/chk-1/foo/1.sst? 🤔
But maybe indeed referencing claimed files by absolute path makes more sense...
| public void serializeOperatorStateHandleUtil( | ||
| OperatorStateHandle stateHandle, DataOutputStream dos) throws IOException { | ||
| serializeOperatorStateHandle(stateHandle, dos); | ||
| serializeOperatorStateHandle(stateHandle, dos, null); |
There was a problem hiding this comment.
Why is it ok to pass null instead of a propper context in some of those calls?
There was a problem hiding this comment.
These are @VisibleForTesting helpers used only from test code, they serdes standalone handles outside any checkpoint, so there is no "exclusive directory" to check relative references, that's why null is the only meaningful context there.
I added clarifications in 30c2e72
| public void serializeKeyedStateHandleUtil(KeyedStateHandle stateHandle, DataOutputStream dos) | ||
| throws IOException { | ||
| serializeKeyedStateHandle(stateHandle, dos); | ||
| serializeKeyedStateHandle(stateHandle, dos, null); |
| static void serializeKeyedStateHandle(KeyedStateHandle stateHandle, DataOutputStream dos) | ||
| throws IOException { | ||
| serializeKeyedStateHandle(stateHandle, dos, null); | ||
| } |
There was a problem hiding this comment.
optional: wouldn't it be safer to just drop this variant and force everyone to expcilitly pass null or some concrete value?
There was a problem hiding this comment.
I can do it. I kept the 2-arg forms to minimize the diff. I think your suggestion makes sense, but I wanted to get a feedback before doing that.
There was a problem hiding this comment.
I dropped context-free methods in 30c2e72 + added justifications to remaining null
| * storeCheckpointMetadata} overload set, so a caller cannot switch between the two encodings by | ||
| * merely changing a variable's static type. | ||
| */ | ||
| public static void storeCheckpointMetadataWithoutExclusiveDir( |
There was a problem hiding this comment.
why do we need to keep the without excelusive dir variant? 🤔 What is/are the use cases justifying it?
I would expect that every checkpoint we are writing has it's own exclusive directory, and incorrectly referencing relative files outside of that should be explicitly forbidden?
There was a problem hiding this comment.
The main use case is the state processor API's deep copy: the carried-over handles refer to outside the directory at write time, while FileCopyFunction puts a copy of every referenced file next to the new _metadata, so on read all relative references are correct. "Forbidding" would break that usage, which is correct.
…out the metadata store variants
rkhachatryan
left a comment
There was a problem hiding this comment.
Thanks for the fix!
The general approach LGTM, I have some minor comments, PTAL
| // location. The trailing separator keeps a sibling directory prefix from matching. | ||
| final String savepointPrefix = | ||
| savepointPath.endsWith("/") ? savepointPath : savepointPath + "/"; | ||
| assertThat(sharedStateFilePaths(checkpointPath)) |
There was a problem hiding this comment.
This might be flaky:
- the 1st checkpoint might not create any SSTs
- and the 2nd checkpoint might compact all the inherited SSTs out
The former can be avoided by re-taking the checkpoint; the latter can be avoided by disabling compactions.
But probably it's enough to just retry the checkpoint-restore for several iterations if this happens?
| * unknown ({@code null}), the legacy behavior applies and the relative encoding is kept. | ||
| */ | ||
| private static boolean canKeepRelativeEncoding( | ||
| RelativeFileStateHandle handle, @Nullable SerializationContext context) { |
There was a problem hiding this comment.
Could you elaborate what those legacy paths are?
I think keeping this nullable makes it a bit more error-prone without giving much value: if this change happens to be problematic there will be no way to disable it anyways
30c2e72 to
1776cfa
Compare
…ind SavepointOutputFormat's encoding choice [FLINK-39964][state-processor-api] Explain the deep-copy pipeline behind SavepointOutputFormat's encoding choice
…ion context explicitly
1776cfa to
2b7a5c8
Compare
What is the purpose of the change
The first incremental RocksDB checkpoint taken after a CLAIM-mode restore from a NATIVE savepoint reuses the savepoint's SST files. The savepoint writes those files as RelativeFileStateHandles, which are carried into the new checkpoint's shared state. The metadata serializer records only their relative path, and on restore they are re-anchored to the new checkpoint's exclusive directory. The referenced state file is therefore looked up under / while the bytes still live in /, so restoring from that checkpoint fails with a missing-file error.
The fix pushes the exclusive directory of the checkpoint being written into metadata serialization and decides, per state handle, whether the compact relative encoding is still safe: a relative handle whose file actually lives in that directory keeps it (so savepoints stay relocatable), and a foreign one (a reused savepoint SST) is persisted with its absolute path using the pre-existing FileStateHandle encoding.
Brief change log
Verifying this change
Does this pull request potentially affect one of the following parts:
@Public(Evolving): (no)Documentation
Was generative AI tooling used to co-author this PR?
Generated-by: Claude Code (Anthropic), Opus 4.8 / Fable.